feat(ui): major UI/UX overhaul for forms and resource management#1
feat(ui): major UI/UX overhaul for forms and resource management#1IvanHunters wants to merge 40 commits intomainfrom
Conversation
- Add CustomObjectFieldTemplate for free-form key-value objects - Add KeyValueEditor component for Helm valuesOverride fields - Add SourceWidget for mutually exclusive source selection (VM Disk) - Add custom button templates (Add, Remove, MoveUp, MoveDown) - Fix select dropdown arrow positioning with proper padding - Install @novnc/novnc for VNC viewer support Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
- Add pre-formatted text display for kubeconfig and large secrets - Add collapse/expand functionality for large secrets - Add dark terminal-like theme for better readability - Show "Show more/Show less" button for secrets larger than 5 lines or 200 chars Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
- VMDisk shows only Overview tab (no Workloads/Services/Ingresses/Secrets) - VMInstance shows additional VNC tab - Other application types show full set of tabs Storage-only resources like VMDisk don't have workloads, services, or ingresses, so hiding those tabs provides cleaner UX. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
- Add VNC tab component with noVNC library integration - Configure Vite proxy for WebSocket support (ws: true) - Use dynamic import for @novnc/novnc to handle CommonJS module Note: VNC WebSocket connection not working yet due to kubectl proxy limitations. kubectl proxy doesn't support WebSocket upgrade for KubeVirt VNC subresources. Temporary solution: use virtctl vnc command directly. Future: implement backend proxy for proper WebSocket support. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
- Fetch StorageClasses from Kubernetes API using useK8sList - Display in select dropdown with (default) marker - Auto-select default StorageClass if no value set - Handle loading and empty states - Register in customWidgets for RJSF forms Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
- Add MutuallyExclusiveField component for radio button selection - Remove unused StorageClassWidget - Clean up rjsf-templates exports Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Watch requests were not filtering by labelSelector/fieldSelector, causing all resources in namespace to be returned instead of only those matching the filter. - Add search parameter to client.watch() method - Pass labelSelector and fieldSelector to watch URLSearchParams - Update hooks to pass selectors from useK8sList to client.watch() This fixes the issue where Secrets tab showed all namespace secrets instead of only those belonging to the current application. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 16 minutes and 22 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (27)
📝 WalkthroughWalkthroughThis pull request introduces comprehensive backup functionality to the console app, adding new form components for editing additional properties and mutually exclusive fields, backup-related CRUD routes, VNC client integration for VM instances, Kubernetes watch filtering support, and custom schema-driven form widgets to handle complex configuration objects. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant ConsoleApp as Console App
participant SchemaForm as SchemaForm<br/>(with Schema<br/>Enrichment)
participant K8sAPI as Kubernetes API
participant AppDefs as App Definitions<br/>(Memory)
User->>ConsoleApp: Navigate to Create Backup
ConsoleApp->>K8sAPI: Fetch Backup CRD Schema
K8sAPI-->>ConsoleApp: OpenAPI Schema
ConsoleApp->>K8sAPI: Fetch ApplicationDefinitions
K8sAPI-->>ConsoleApp: App Definitions List
ConsoleApp->>K8sAPI: Fetch Application Instances
K8sAPI-->>ConsoleApp: Instances List
ConsoleApp->>SchemaForm: Render with Enriched Schema<br/>(enums from fetched data)
SchemaForm-->>ConsoleApp: Schema Form UI
ConsoleApp-->>User: Display Form
User->>SchemaForm: Fill in Fields & Submit
SchemaForm->>ConsoleApp: Validate & Prepare Backup CR
ConsoleApp->>K8sAPI: Create Backup Resource
K8sAPI-->>ConsoleApp: Resource Created
ConsoleApp->>User: Navigate to Backups List
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~75 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Create StorageClassWidget that dynamically loads storage classes from Kubernetes API and displays them in a dropdown instead of text input. - Add StorageClassWidget component with K8s API integration - Register widget in RJSF custom widgets - Auto-apply widget to all storageClass fields via uiSchema - Remove duplicate label rendering (RJSF handles it) Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
- Add overflow scrolling to sidebar for long menus - Fix TenantsPage to show Tenant CR from current namespace - Add Edit button for each tenant linking to edit form - Fix CustomObjectFieldTemplate to support additionalProperties as object - Enable resourceQuotas field editing with KeyValueEditor Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
…ection Add custom RJSF widget that automatically loads and displays available storage classes from the cluster. The widget: - Fetches StorageClass resources from storage.k8s.io/v1 API - Automatically selects default storage class if available - Shows loading state during fetch - Marks default storage class with (default) label - Automatically applied to all 'storageClass' fields in schemas via recursive schema traversal Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Fix RFB constructor import from @novnc/novnc CommonJS module. The module exports with nested default structure (module.default.default), which required using dynamic import with proper fallback chain to access the RFB constructor. Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Update TenantsPage to use TenantNamespace resources directly from context instead of fetching Tenant ApplicationInstances. Display additional metadata: - Namespace name - Host label - Enabled modules (etcd, ingress, monitoring, seaweedfs) Remove edit action button and status column for cleaner table layout. Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
- Remove object schema check from CustomObjectFieldTemplate additionalProperties logic - Remove overflow-y-auto from Sidebar for cleaner scrolling behavior Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Remove WebSocket proxy config from vite.config and unused labelSelector/fieldSelector parameters from watch() method. These were part of earlier implementation that is no longer needed. Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Remove Ingresses and Secrets tabs from VMInstance detail page. VMInstance now shows only relevant tabs: - Overview - Workloads - Services - VNC Other application types (Kubernetes, PostgreSQL, etc.) continue to show the full tab set including Ingresses and Secrets. Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Restore labelSelector and fieldSelector parameters in watch() method to enable filtered watch requests. This fixes the issue where watch requests were fetching all resources without label-based filtering, causing unnecessary data to be loaded (e.g., all tenantsecrets instead of only those belonging to a specific application). Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Fix label-based filtering for Secrets, Services, and Ingresses: - Add application.group label to appInstanceLabel selector (required by all resources) - Secrets: Use core Secrets API instead of TenantSecret API, filter by tenantresource=false to show internal application secrets - Services/Ingresses: Add tenantresource=true filter to show only tenant-facing resources This matches the filtering logic from the old dashboard and correctly filters resources belonging to specific applications. Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Simplify appInstanceLabel() by removing redundant application.group filter - kind and name are sufficient to uniquely identify application resources. Update detail tabs to use simplified label selectors: - Remove internal.cozystack.io/tenantresource filter - Switch SecretsTab from core Secrets to TenantSecrets (core.cozystack.io/v1alpha1) - Update UI text from 'Secrets' to 'Tenant secrets' Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Remove labelSelector and fieldSelector parameters from watch() method. These were added earlier but are not needed since list() already supports filtering and watch reuses the same resource version. Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Switch from TenantSecrets CRD to standard v1 Secrets with internal.cozystack.io/tenantresource=false filter to show application-specific secrets. Add collapsible sections for each secret with chevron indicators, showing secret type and key count in collapsed state. Fix watch method to properly pass labelSelector and fieldSelector parameters to Kubernetes API for correct resource filtering. Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Add eye icon button next to each secret to reveal/hide all keys at once. Individual key reveal buttons still work independently. Removed global "Expand all" feature in favor of per-secret reveal control for better UX. Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Add flex layout and overflow-y-auto to sidebar nav element to allow scrolling when menu sections exceed viewport height. Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Add Edit button to Tenants page for editing Tenant CRs in current namespace instead of just viewing TenantNamespace list. Improve free-form object detection in CustomObjectFieldTemplate to handle additionalProperties with schema objects (not just boolean true), enabling KeyValueEditor for fields like resourceQuotas. Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Fix module detection on Tenants page to use TenantModule CRs instead of namespace labels. Load all TenantModules via K8s API and determine enabled modules by checking if corresponding TenantModule resources exist in tenant namespace. This resolves inverted logic where tenants with all modules enabled showed none, and vice versa. Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Add complete backup management section to console UI: - Backups sidebar section with Plans, Backup Jobs, Backups, and Restore Jobs - List pages for all backup resource types with Create buttons - Dynamic form pages with smart selects: - Kind selector triggers dynamic instance loading - Plan/Backup/BackupClass selects with fallback for empty lists - Real-time data fetching from Kubernetes API - Separate specialized pages for Plans and RestoreJobs with dependent field logic - Generic pages for BackupJobs and Backups using shared components Signed-off-by: Aleksei Sviridkin <f@lex.la> Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Change module detection from TenantModule resources to namespace labels: - Use namespace.cozystack.io/* labels for module status - Define MODULE_LABELS mapping with key-label pairs - Extract enabled modules from namespace labels - Simplify logic by removing K8s API calls for TenantModules Signed-off-by: Aleksei Sviridkin <f@lex.la> Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Change module display logic to use actual TenantModule resources: - Fetch all TenantModules via K8s API using useK8sList - Group modules by namespace for each tenant - Exclude 'info' module from display (default module) - Show real-time module status from CRD instead of namespace labels This provides accurate module information synchronized with cluster state. Signed-off-by: Aleksei Sviridkin <f@lex.la> Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
- Use TenantSecret API for Tenant resources from status.namespace - Apply tenantresource=true filter for Info and tenant-scoped apps - Apply tenantresource=false filter for regular applications - Show all secrets without label filtering for Tenant resources Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
- Add AdditionalPropertiesField component for fields with additionalProperties schema (e.g., users field in bucket with per-user readonly checkbox) - Auto-detect additionalProperties fields in SchemaForm and apply custom field - Remove Submit button from all forms using custom ButtonTemplates - Remove internal.cozystack.io/tenantresource label filter from secrets tab to show all application-related secrets Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
…ality Generate backup resource schemas dynamically from CRD definitions instead of using hardcoded schemas. Add full CRUD operations for all backup resources. Changes: - Add useCRDSchema hook to fetch OpenAPI schemas from CRD definitions - Update all backup create pages to use CRD schemas - Enrich schemas with enum values for dropdowns (plans, backups, kinds) - Add BackupResourceEditPage for editing existing resources - Add edit and delete buttons to BackupResourceListPage - Add edit routes for all backup resource types This ensures schemas are always in sync with CRD definitions and includes all required fields (applicationRef, strategyRef, takenAt for Backups). Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
…defaults - Create dedicated BackupCreatePage for Backup resources - Add default apiGroup value in Plan and RestoreJob forms - Improve UX with pre-filled apiGroup field Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
- Show only 'enabled' checkbox for disabled addons - Expand additional fields when addon is enabled - Add visual grouping for enabled addon fields - Improve form compactness for apps with many addons (e.g., Kubernetes) Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Replace the generic "Chart Values" title from Helm schemas with more user-friendly "Parameters" label in the sanitizeSchema function. Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
7ad2875 to
4849186
Compare
There was a problem hiding this comment.
Code Review
This pull request implements a comprehensive backup management interface for handling plans, jobs, and restore operations, and introduces a VNC console for VM instances using noVNC. It also enhances the form system with custom RJSF templates and widgets, such as a StorageClass selector and key-value editor, and improves the secrets management UI. Feedback identifies a critical bug in the backup update mutation, recommends a more robust method for deriving resource kinds, and highlights issues with hardcoded WebSocket URLs and state synchronization in the new components.
| } | ||
|
|
||
| try { | ||
| await updateMutation.mutateAsync({ name: name!, resource: updated }) |
There was a problem hiding this comment.
The updateMutation.mutateAsync call is passing an object with name and resource properties, but the useK8sUpdate hook's mutationFn expects the resource object directly as its first argument. This will cause the update to fail at runtime because the hook attempts to access body.metadata.name, which will be undefined on the wrapper object.
| await updateMutation.mutateAsync({ name: name!, resource: updated }) | |
| await updateMutation.mutateAsync(updated) |
|
|
||
| const resource = { | ||
| apiVersion: "backups.cozystack.io/v1alpha1", | ||
| kind: title.slice(0, -1), // Remove 's' from plural title |
There was a problem hiding this comment.
Deriving the Kubernetes resource kind by slicing the plural title is fragile and incorrect for multi-word resources. For example, if title is "Backup Jobs", the derived kind will be "Backup Job" (with a space), which is not a valid Kubernetes kind (it should be "BackupJob"). It is safer to use an explicit mapping based on the resourceType prop.
| kind: title.slice(0, -1), // Remove 's' from plural title | |
| kind: resourceType === "backupjobs" ? "BackupJob" : resourceType === "restorejobs" ? "RestoreJob" : resourceType === "plans" ? "Plan" : "Backup", |
| let mounted = true | ||
|
|
||
| // Build WebSocket URL through kubectl proxy | ||
| const wsUrl = `ws://localhost:8001/apis/subresources.kubevirt.io/v1/namespaces/${ns}/virtualmachineinstances/${instance.metadata.name}/vnc` |
There was a problem hiding this comment.
The VNC WebSocket URL is hardcoded to localhost:8001, which is only suitable for local development using kubectl proxy. In a production environment, this will fail. Additionally, using the ws:// protocol will be blocked by browsers as mixed content if the console is served over https://. The URL should be constructed dynamically using window.location.host and the appropriate protocol (ws: or wss:).
| const [pairs, setPairs] = useState<KeyValuePair[]>(() => { | ||
| return Object.entries(value || {}).map(([key, val]) => ({ | ||
| key, | ||
| value: typeof val === "string" ? val : JSON.stringify(val), | ||
| })) | ||
| }) |
There was a problem hiding this comment.
The pairs state is initialized only once when the component mounts. If the value prop changes from the parent (e.g., due to a form reset or switching between different resources), the internal state will not synchronize, leading to stale data being displayed. Consider using a useEffect to sync the state when the value prop changes, or ensuring the parent component uses a unique key to force a re-mount when the underlying data changes.
Add visual confirmation for options without configuration fields (like upload). Show selected state with checkmark and informative message in a styled card. Add helpful hint for upload option about post-creation image upload workflow. Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Replace SourceWidget with SourceField to properly handle mutually exclusive source options in VM Disk form. RJSF renders objects with properties as separate fields when using widgets, but custom fields can override the entire object rendering. This fixes the issue where source options (image/http/upload/disk) were rendered as separate fieldsets instead of radio buttons. Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
There was a problem hiding this comment.
Actionable comments posted: 13
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🟡 Minor comments (25)
apps/console/src/routes/TenantsPage.tsx-35-50 (1)
35-50:⚠️ Potential issue | 🟡 MinorSimplify
modulesByNamespace— the outer condition is dead code and the inner filter is O(n²).Line 43's outer check
name !== "info" || count > 1is effectively a no-op: the inner block only pushes whenname !== "info", so whenname === "info"the inner always skips regardless of the outer count, and whenname !== "info"the outer is already true. The net behavior is simply "push every non-info module" — the "info is default, keep it when it's the only one" intent implied by the comment is never realized. Additionally, the.filter(...)insideforEachmakes this O(n²) over all modules.If "info" should always be hidden, drop the dead logic. If "info" should be shown only when it's the sole module in the namespace, precompute counts once.
🛠 Proposed fix (drop dead logic, single pass)
- // Group modules by namespace - const modulesByNamespace = useMemo(() => { - const map = new Map<string, string[]>() - modulesData?.items.forEach((mod) => { - const ns = mod.metadata.namespace - if (!map.has(ns)) { - map.set(ns, []) - } - // Only include info module if it's not the only module (info is default) - if (mod.metadata.name !== "info" || (modulesData?.items.filter(m => m.metadata.namespace === ns).length ?? 0) > 1) { - if (mod.metadata.name !== "info") { - map.get(ns)!.push(mod.metadata.name) - } - } - }) - return map - }, [modulesData]) + // Group modules by namespace (hide the default "info" module) + const modulesByNamespace = useMemo(() => { + const map = new Map<string, string[]>() + modulesData?.items.forEach((mod) => { + if (mod.metadata.name === "info") return + const ns = mod.metadata.namespace + const list = map.get(ns) + if (list) list.push(mod.metadata.name) + else map.set(ns, [mod.metadata.name]) + }) + return map + }, [modulesData])🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/routes/TenantsPage.tsx` around lines 35 - 50, modulesByNamespace currently performs an O(n²) filter and contains dead logic — change it to a two-step single-pass approach: first build a Map of namespace -> count from modulesData?.items (use mod.metadata.namespace) to know how many modules per namespace, then iterate modulesData?.items once and push mod.metadata.name into map only for non-"info" modules, and for "info" push it only when the precomputed count for that namespace is 1; update references to modulesByNamespace, modulesData?.items, mod.metadata.name and mod.metadata.namespace accordingly to remove the inner .filter and the redundant outer condition.apps/console/src/routes/detail/VncTab.tsx-33-33 (1)
33-33:⚠️ Potential issue | 🟡 MinorSimplify the RFB import to use
module.defaultdirectly.The
@novnc/novnc@1.6.0package exports RFB as a standard ESM default export (export default class RFB), somodule.defaultshould be the RFB class directly. The current fallback chain(module as any).default?.default || module.default || moduleis overly defensive and the final fallback tomodule(the entire namespace object) would fail onnew RFB(...)with a confusing error. Replace with simplymodule.defaultto get the actual RFB constructor, with a proper type annotation instead ofany.This applies to both line 33 and line 131.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/routes/detail/VncTab.tsx` at line 33, The RFB import fallback chain is overly defensive and can yield the namespace object instead of the constructor; replace occurrences where RFB is resolved (the expression currently using (module as any).default?.default || module.default || module) with a direct module.default access and add a proper type for the module (use the RFB constructor type) instead of casting to any; update both places referenced in VncTab.tsx (the resolution at the original import and the second occurrence around line 131) so new RFB(...) receives the actual class constructor.apps/console/src/routes/detail/SecretsTab.tsx-185-187 (1)
185-187:⚠️ Potential issue | 🟡 MinorImport
Tenantand narrow the type cast instead of usingas any.The
Tenantinterface withstatus.namespaceis properly defined in@cozystack/types, making theas anycast unnecessary. Since the code already checksisTenantbefore accessing the namespace, casting toTenantprovides proper type safety and satisfies the@typescript-eslint/no-explicit-anyrule.🔧 Proposed fix
-import type { ApplicationDefinition, ApplicationInstance } from "@cozystack/types" +import type { ApplicationDefinition, ApplicationInstance, Tenant } from "@cozystack/types"const ns = isTenant - ? (instance.status as any)?.namespace ?? instance.metadata.namespace ?? "" + ? (instance as Tenant).status?.namespace ?? instance.metadata.namespace ?? "" : instance.metadata.namespace ?? ""🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/routes/detail/SecretsTab.tsx` around lines 185 - 187, Replace the use of a broad any cast when deriving ns: instead cast instance to Tenant when isTenant is true and access status.namespace, i.e. narrow the type instead of using "as any"; update the expression that computes ns (the variables isTenant, instance.status, instance.metadata and ns) to cast instance to Tenant in the branch where isTenant is true so TypeScript uses Tenant.status.namespace safely and the `@typescript-eslint/no-explicit-any` lint error is removed.apps/console/src/lib/use-crd-schema.ts-1-1 (1)
1-1:⚠️ Potential issue | 🟡 MinorRemove unused
useQueryimport.ESLint flags this as unused. Since
useK8sGetalready encapsulates the query, the direct import is not needed.-import { useQuery } from "@tanstack/react-query" import { useK8sGet } from "@cozystack/k8s-client"🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/lib/use-crd-schema.ts` at line 1, Remove the unused import of useQuery from the top of the file; the file currently imports useQuery but relies on useK8sGet to encapsulate the query logic, so delete the import line referencing useQuery to satisfy ESLint and avoid dead code.apps/console/src/routes/BackupResourceListPage.tsx-117-124 (1)
117-124:⚠️ Potential issue | 🟡 MinorStatus text falls back to raw "True"/"False" condition status.
When
phaseis absent,statusTextbecomesready.status(literal"True"/"False"/"Unknown"), which is exposed to the user as the status label. That's confusing UX — users see "True" in a badge and have to infer it means "Ready".♻️ Suggested mapping
- const phase = item.status?.phase - const ready = item.status?.conditions?.find((c) => c.type === "Ready") - const statusText = phase || ready?.status || "Unknown" - const statusTone = - statusText === "Completed" || statusText === "True" ? "ok" : - statusText === "Failed" || statusText === "False" ? "error" : - "warn" + const phase = item.status?.phase + const ready = item.status?.conditions?.find((c) => c.type === "Ready") + const readyText = + ready?.status === "True" ? "Ready" + : ready?.status === "False" ? "Not Ready" + : ready?.status === "Unknown" ? "Unknown" + : undefined + const statusText = phase || readyText || "Unknown" + const statusTone = + statusText === "Completed" || statusText === "Ready" ? "ok" : + statusText === "Failed" || statusText === "Not Ready" ? "error" : + "warn"🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/routes/BackupResourceListPage.tsx` around lines 117 - 124, The UI shows raw condition statuses ("True"/"False") because statusText falls back to ready.status; change the fallback to a user-facing label by mapping ready.status values to friendly strings (e.g., "True" -> "Ready", "False" -> "Not Ready", undefined/other -> "Unknown") where statusText is computed in the items.map block, and then compute statusTone based on these mapped labels (or check ready.status booleans first) so badges display "Ready"/"Not Ready"/"Unknown" instead of raw "True"/"False"; update the logic around phase, ready, statusText, and statusTone accordingly.apps/console/src/routes/BackupResourceListPage.tsx-1-2 (1)
1-2:⚠️ Potential issue | 🟡 MinorRemove unused
useNavigateimport andnavigatevariable.ESLint flags line 32 as unused. Since no in-component navigation happens here (deletion just refetches), drop both.
-import { Link, useNavigate } from "react-router" +import { Link } from "react-router" ... - const navigate = useNavigate()🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/routes/BackupResourceListPage.tsx` around lines 1 - 2, The component imports and declares navigation utilities that are unused: remove the unused import useNavigate and delete the navigate variable/const (from the BackupResourceListPage component) so ESLint no longer flags it; keep Archive, Plus, Edit, Trash2 and ensure deletion logic continues to only refetch without performing any navigation.apps/console/src/lib/use-crd-schema.ts-41-42 (1)
41-42:⚠️ Potential issue | 🟡 MinorPicking
versions[0]is not guaranteed to be the served/storage version.A CRD may declare multiple versions, and the first entry in
spec.versionsisn’t necessarily the one currently served or marked as storage. Prefer the version wherestorage === true(orserved === true), falling back toversions[0]only if none is explicitly marked.♻️ Suggested refactor
- // Extract the schema from the first version's spec field - const schema = crd?.spec?.versions?.[0]?.schema?.openAPIV3Schema?.properties?.spec + // Prefer the storage version, then any served version, else fall back to the first. + const versions = crd?.spec?.versions ?? [] + const chosen = + versions.find((v) => (v as CRDVersion & { storage?: boolean }).storage) ?? + versions.find((v) => (v as CRDVersion & { served?: boolean }).served) ?? + versions[0] + const schema = chosen?.schema?.openAPIV3Schema?.properties?.specYou may also want to extend the
CRDVersioninterface withserved?: booleanandstorage?: boolean.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/lib/use-crd-schema.ts` around lines 41 - 42, The current extraction uses versions[0] which may not be the served/storage version; update the logic that computes schema to first find the CRD version where storage === true (or served === true if storage not present), falling back to versions[0] only if no matching version is found, then read thatVersion.schema.openAPIV3Schema.properties.spec; also extend the CRDVersion type/interface to include optional served?: boolean and storage?: boolean to accurately type-check the lookup.apps/console/src/components/AdditionalPropertiesWidget.tsx-15-26 (1)
15-26:⚠️ Potential issue | 🟡 MinorPrefer inline validation over
alert()for the duplicate-key case.
alert("Key "{newKey}" already exists")is blocking and inconsistent with the rest of the form’s styling. Consider surfacing the duplicate-key state inline (e.g., red border on the input + helper text) and/or disabling the Add button whennewKey in (value ?? {}). This also lets keyboard users handle the error without dismissing a modal dialog.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/components/AdditionalPropertiesWidget.tsx` around lines 15 - 26, Replace the blocking alert in handleAddKey with inline validation: add a local state (e.g., duplicateKeyError or isDuplicate) that checks newKey against (value ?? {}) and set that state instead of calling alert; update the input to show a validation state (red border/helper text) when duplicateKeyError is true and disable the Add button when newKey is empty or duplicate; keep the rest of the logic (using itemSchema to compute defaultValue, calling onChange and setNewKey("")) unchanged so successful adds still work.apps/console/src/routes/BackupCreatePage.tsx-106-145 (1)
106-145:⚠️ Potential issue | 🟡 MinorReplace
alert()with inline/toast errors.
alert()is used for all four validation paths and the create-failure branch. This blocks the UI thread, can't be styled or localized, and diverges from the rest of this console app's UX (the same file uses<Section>,<Spinner>, etc. from@cozystack/ui). Prefer setting an error state rendered inline near the relevant field, or use a toast/notification primitive if one exists in@cozystack/ui.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/routes/BackupCreatePage.tsx` around lines 106 - 145, Replace all blocking alert() calls in handleSubmit with UI-friendly error reporting: use local state (e.g., validationError / fieldErrors) to store per-field validation messages and render them inline next to the corresponding inputs (applicationRef, strategyRef, takenAt, name) instead of calling alert; for the create-failure branch use the app’s notification/toast primitive (from `@cozystack/ui`) or a global error state to show a non-blocking error containing (err as Error).message; update handleSubmit to set these states where validation fails and to show a toast on catch instead of alert, and ensure createMutation.mutateAsync and navigate usage remain unchanged.apps/console/src/components/KeyValueEditor.tsx-15-20 (1)
15-20:⚠️ Potential issue | 🟡 MinorInternal state doesn’t sync when the
valueprop changes.The initial
pairslist is built only once via the lazyuseStateinitializer. If the parent updatesvalueasynchronously — e.g., the wrappingCustomObjectFieldTemplatere-renders with data fetched from the server on an edit page, or another field’s change causes a schema reset — the editor will keep showing the initial (often empty) pairs. For the new backup edit flows this will look like the overrides silently disappeared.Either derive pairs directly from
valueon each render, or add an effect that re-syncs whenvaluechanges from outside (e.g., using a shallow equality check to avoid clobbering in-progress edits).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/components/KeyValueEditor.tsx` around lines 15 - 20, The component initializes pairs via the lazy useState initializer and never updates them when the incoming value prop changes; add sync logic so external changes to value update pairs: either derive pairs from value on each render or add a useEffect watching value that computes newPairs (map Object.entries(value || {}) to {key, value: typeof val === "string" ? val : JSON.stringify(val)}) and calls setPairs only when the newPairs differ from the current pairs (use a shallow equality check on keys/values to avoid clobbering in-progress edits). Refer to the pairs state and setPairs and the initial useState initializer in KeyValueEditor to locate where to apply the effect or derivation.apps/console/src/routes/BackupCreatePage.tsx-129-145 (1)
129-145:⚠️ Potential issue | 🟡 MinorGuard submit when
tenantNamespaceis missing.On line 134 the resource is built with
namespace: tenantNamespacewithout a null check. IfuseTenantContext()hasn’t resolved a namespace, this sendsnamespace: undefinedtocreateMutation.mutateAsync, which will either be rejected by the server or — worse — routed to the default namespace depending on the client. Add an early validation similar to the otheralertchecks, or disable the submit button whiletenantNamespaceis falsy.🛡️ Proposed early check
if (!formData.takenAt) { alert("Taken at timestamp is required") return } + + if (!tenantNamespace) { + alert("No tenant namespace selected") + return + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/routes/BackupCreatePage.tsx` around lines 129 - 145, The submit builds a resource using namespace: tenantNamespace without checking it; add an early guard before constructing or calling createMutation.mutateAsync to prevent sending namespace: undefined—e.g., if tenantNamespace is falsy alert the user and return (similar to other alert checks) or ensure the submit button is disabled while tenantNamespace is not set; reference the tenantNamespace variable and the createMutation.mutateAsync call so you place the guard immediately before resource construction/await createMutation.mutateAsync.apps/console/src/components/SchemaForm.tsx-13-73 (1)
13-73:⚠️ Potential issue | 🟡 MinorRecursion misses array items and composite schemas.
Both
addStorageClassWidgetsandaddAdditionalPropertiesWidgetsonly descend intoschema.properties. AstorageClassfield inside an array item (e.g.,volumes: { type: "array", items: { properties: { storageClass: {...} } } }) — which is common in CRD-driven schemas — will not pick up the widget, and likewise for schemas composed viaallOf/oneOf/anyOf. Given the PR’s goal of applying StorageClassWidget to “all forms with a storageClass field”, this could silently miss real fields.♻️ Suggested handling for array items (apply similarly to additionalProperties recursion)
for (const [key, value] of Object.entries(properties)) { if (key === "storageClass" && typeof value === "object" && (value as any).type === "string") { // Found a storageClass field - add widget result[key] = { ...result[key], "ui:widget": "StorageClassWidget", } } else if (typeof value === "object" && (value as any).properties) { // Recursively process nested objects result[key] = addStorageClassWidgets(value as RJSFSchema, result[key] as UiSchema) + } else if ( + typeof value === "object" && + (value as any).type === "array" && + (value as any).items && + typeof (value as any).items === "object" + ) { + // Recurse into array item schemas + const items = (value as any).items + result[key] = { + ...result[key], + items: addStorageClassWidgets(items, (result[key] as UiSchema)?.items as UiSchema), + } } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/components/SchemaForm.tsx` around lines 13 - 73, The current recursion in addStorageClassWidgets and addAdditionalPropertiesWidgets only traverses schema.properties and thus misses storageClass inside arrays and composite schemas; update both functions to also descend into schema.items (for arrays), schema.additionalProperties (when it is a schema), and each subschema in schema.allOf/oneOf/anyOf by recursively calling the same function on those schemas, and merge/attach uiSchema entries appropriately (e.g., propagate result[key] to its .items or composite index entries) so StorageClassWidget and AdditionalPropertiesField get applied for nested array items and composed schemas; modify references inside addStorageClassWidgets and addAdditionalPropertiesWidgets to detect and recurse into items, additionalProperties, and allOf/oneOf/anyOf.apps/console/src/components/StorageClassWidget.tsx-30-34 (1)
30-34:⚠️ Potential issue | 🟡 MinorAuto-default
useEffectprevents users from clearing the field.When the field is not required (line 44 renders
-- None --), selecting it callsonChange(undefined)(line 39). On the very next render!valueis true again, so this effect immediately re-fires and writes the default back — users can never intentionally leave the field empty on clusters that have a default StorageClass.Use an init ref so the default is applied only once:
🐛 Proposed fix
-import { useEffect } from "react" +import { useEffect, useRef } from "react" ... export function StorageClassWidget(props: WidgetProps) { const { value, onChange, required, disabled, readonly, placeholder } = props + const didInitRef = useRef(false) ... - // Set default storage class if value is empty and default exists - useEffect(() => { - if (!value && defaultSC && !isLoading) { - onChange(defaultSC.metadata.name) - } - }, [value, defaultSC, isLoading, onChange]) + // Set default storage class once on first load if value is empty + useEffect(() => { + if (didInitRef.current || isLoading) return + if (!value && defaultSC) { + onChange(defaultSC.metadata.name) + } + didInitRef.current = true + }, [value, defaultSC, isLoading, onChange])Also,
placeholderis destructured on line 16 but never used (matches the ESLint hint) — drop it from the destructure.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/components/StorageClassWidget.tsx` around lines 30 - 34, The current useEffect that auto-applies the default StorageClass repeatedly re-triggers when value is falsy, preventing users from clearing the field; change the logic in the useEffect in StorageClassWidget to only apply defaultSC once by introducing an init ref (e.g., let appliedDefaultRef = useRef(false)) and only call onChange(defaultSC.metadata.name) when !appliedDefaultRef.current, then set appliedDefaultRef.current = true after applying; also remove the unused placeholder from the props destructure to satisfy ESLint.apps/console/src/components/MutuallyExclusiveField.tsx-110-117 (1)
110-117:⚠️ Potential issue | 🟡 MinorRadio group
namecollides whentitleis undefined.
name={${title}-source}becomes"undefined-source"for any instance without a title; two such components on the same page will share a radio group and selecting one clears the other. Fall back to a unique identifier (e.g., auseId()value) instead of interpolating an optional string.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/components/MutuallyExclusiveField.tsx` around lines 110 - 117, The radio input name currently interpolates title (name={`${title}-source`}) which yields "undefined-source" when title is missing and causes distinct MutuallyExclusiveField instances to share a radio group; update the component (MutuallyExclusiveField) to derive a stable fallback id using React's useId() and use that id when title is falsy (e.g., const id = useId(); const groupName = title ? `${title}-source` : `${id}-source`) and assign groupName to the input name prop so each instance gets a unique radio group when title is undefined.apps/console/src/routes/BackupResourceCreatePageWithData.tsx-16-42 (1)
16-42:⚠️ Potential issue | 🟡 MinorEnum injection only descends into
properties; missesitems,oneOf,anyOf, schema-valuedadditionalProperties.If a CRD schema wraps the target field in an array, a union, or a
$ref-resolvedallOf/oneOfbranch, the dot-path walk will silently not hit it and the dropdown will render as a plain text input. Worth handling at leastitemsandoneOf/anyOf, or logging a warning when anenumMapkey does not resolve so misconfigurations don’t fail silently in the UI.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/routes/BackupResourceCreatePageWithData.tsx` around lines 16 - 42, The enrichSchemaWithEnums function only traverses .properties so enums nested under arrays, unions or schema-valued additionalProperties never get injected; update enrichSchemaWithEnums to also descend into schema slots: check and recurse into result.items, each branch of result.oneOf/result.anyOf/result.allOf, and result.additionalProperties when it's an object, and resolve $ref/allOf branches if your resolver is available; after traversal, if enumMap contains keys that were not matched, emit a warning/log with the unresolved path so misconfigurations don’t fail silently. Ensure you reference enrichSchemaWithEnums and enumMap when adding these extra recursion cases and the warning.apps/console/src/routes/BackupPlanCreatePage.tsx-82-112 (1)
82-112:⚠️ Potential issue | 🟡 MinorGuard
JSON.parse(baseSchema)and validate the enum map paths resolve.Same pattern as
BackupResourceCreatePageWithData.tsx— a malformedbaseSchemawill throw during render. Additionally,enrichSchemaWithEnumsonly walksproperties, so any mismatch between the dot-paths here (applicationRef.kind,applicationRef.name,backupClassName) and the actual CRD schema shape fails silently. Consider logging when a path inenumMapdid not match any node during enrichment to catch schema drift early.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/routes/BackupPlanCreatePage.tsx` around lines 82 - 112, The JSON.parse(baseSchema) call inside the useMemo may throw on malformed input and the enumMap keys may not resolve to nodes that enrichSchemaWithEnums actually walks, so wrap JSON.parse(baseSchema) in a safe parse (try/catch) and return null on parse failure; before calling enrichSchemaWithEnums validate that the dot-paths you set on enumMap (e.g. "applicationRef.kind", "applicationRef.name", "backupClassName") actually resolve into the parsed base schema (check nested properties via the same property traversal logic used by enrichSchemaWithEnums or reuse a helper to resolve paths) and only add entries that resolve; also modify or augment enrichSchemaWithEnums to emit a log/warning when a requested enum path cannot be found so schema drift is visible (or capture missing-paths here and log them via your logger) and finally keep the existing default assignment on enriched.properties.applicationRef.properties.apiGroup only after confirming those nested objects exist.apps/console/src/components/rjsf-templates.tsx-1-14 (1)
1-14:⚠️ Potential issue | 🟡 MinorRemove unused imports.
ESLint flags
FieldTemplateProps(line 5) andAdditionalPropertiesField(line 14) as unused.AdditionalPropertiesFieldin particular isn’t registered anywhere in this registry — dead import.Suggested diff
import type { IconButtonProps, TemplatesType, WidgetsType, - FieldTemplateProps, FormContextType, RJSFSchema, StrictRJSFSchema, } from "@rjsf/utils" import { CustomObjectFieldTemplate } from "./CustomObjectFieldTemplate.tsx" import { SourceWidget } from "./SourceWidget.tsx" import { StorageClassWidget } from "./StorageClassWidget.tsx" import { AdditionalPropertiesWidget } from "./AdditionalPropertiesWidget.tsx" -import { AdditionalPropertiesField } from "./AdditionalPropertiesField.tsx"🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/components/rjsf-templates.tsx` around lines 1 - 14, Remove the dead imports flagged by ESLint: delete the unused FieldTemplateProps import and the never-registered AdditionalPropertiesField import from the top of the rjsf templates module; ensure only used symbols (e.g., IconButtonProps, TemplatesType, WidgetsType, FormContextType, RJSFSchema, StrictRJSFSchema) remain so imports match actual usage in this file (references: FieldTemplateProps, AdditionalPropertiesField).apps/console/src/components/AdditionalPropertiesField.tsx-26-37 (1)
26-37:⚠️ Potential issue | 🟡 MinorTrim inconsistency lets users create whitespace-padded duplicate keys.
newKey.trim()gates acceptance on line 27, but the duplicate check on line 28 and the key write on line 35 use the rawnewKey. A user entering"foo"followed by" foo "creates two distinct entries. Normalize once and reuse.🐛 Suggested fix
const handleAddKey = () => { - if (!newKey.trim()) return - if (formData && newKey in formData) { - alert(`Key "${newKey}" already exists`) + const trimmed = newKey.trim() + if (!trimmed) return + if (formData && trimmed in formData) { + alert(`Key "${trimmed}" already exists`) return } // Initialize with default value from schema const defaultValue = itemSchema.type === "object" ? {} : itemSchema.default ?? "" - onChange({ ...formData, [newKey]: defaultValue }) + onChange({ ...formData, [trimmed]: defaultValue }) setNewKey("") }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/components/AdditionalPropertiesField.tsx` around lines 26 - 37, In handleAddKey normalize newKey once (e.g., const trimmedKey = newKey.trim()) and use trimmedKey for the empty check, duplicate check (against formData), the key inserted into onChange, and the alert text; keep setNewKey("") (or setNewKey(trimmedKey) then clear) to avoid creating whitespace-padded duplicate keys and ensure consistency when using itemSchema, formData, onChange and setNewKey.apps/console/src/routes/BackupResourceCreatePage.tsx-76-127 (1)
76-127:⚠️ Potential issue | 🟡 MinorMissing “schema failed to load” fallback — sibling pages render an error, this one renders a blank form.
When
schemaLoadingisfalseandschemais still falsy (eithercrdSchemamissing and nooverrideSchema), the header + empty Name input render with no indication that the form is broken.BackupPlanCreatePageandBackupRestoreJobCreatePageboth handle this with an explicit!schemabranch — worth aligning for UX consistency.if (schemaLoading) { return ( <div className="flex items-center gap-2 p-8 text-slate-500"> <Spinner /> Loading schema... </div> ) } + + if (!schema) { + return ( + <div className="p-8 text-red-600"> + Failed to load {title} schema. Please refresh the page. + </div> + ) + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/routes/BackupResourceCreatePage.tsx` around lines 76 - 127, The page currently shows the header and Name input when schemaLoading is false but schema is falsy, leaving the form broken; add the same explicit !schema fallback used in BackupPlanCreatePage and BackupRestoreJobCreatePage so users see an error state instead of an empty form. After the schemaLoading check in BackupResourceCreatePage, add a branch that checks if (!schema) and returns the same error UI (message + possible retry/navigation control) used by the sibling pages, ensuring SchemaForm (openAPISchema prop) and the rest of the form are not rendered when schema is missing; reference the schemaLoading and schema variables and the SchemaForm/handleSubmit components to locate where to insert this fallback.apps/console/src/routes/BackupResourceCreatePageWithData.tsx-86-139 (1)
86-139:⚠️ Potential issue | 🟡 Minor
JSON.parse(baseSchema)can throw insideuseMemo.If
useCRDSchemaever returns a non-JSON string (e.g., an error payload, partial response), the throw propagates out of render and there is no user-visible fallback. Wrap in try/catch and render the same error state as!schema.🛡️ Suggested guard
const enrichedSchema = useMemo(() => { if (!baseSchema) return null - const base = JSON.parse(baseSchema) + let base: any + try { + base = JSON.parse(baseSchema) + } catch (err) { + console.error("Failed to parse CRD schema", err) + return null + } const enumMap: Record<string, string[]> = {}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/routes/BackupResourceCreatePageWithData.tsx` around lines 86 - 139, The JSON.parse(baseSchema) call inside the useMemo that computes enrichedSchema can throw and crash rendering; wrap the parse/enrichment logic in a try/catch within the useMemo (the function where enrichedSchema is computed) and on error return null (same as the !schema case) so the component renders the existing error/fallback UI; ensure you still compute enumMap and call enrichSchemaWithEnums only when parsing succeeds, and keep the same dependency list ([baseSchema, resourceType, appDefs, plansData, backupsData, backupClassesData]).apps/console/src/components/AdditionalPropertiesEditor.tsx-33-33 (1)
33-33:⚠️ Potential issue | 🟡 MinorAvoid
alert()for duplicate-key feedback.Blocking
alert()is inconsistent with the rest of the console UI. Prefer inline validation text next to the input (e.g., a red helper label) or a toast. This also improves a11y.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/components/AdditionalPropertiesEditor.tsx` at line 33, Replace the blocking alert call used for duplicate keys in AdditionalPropertiesEditor (the code path using newKey) with non-blocking UI feedback: update the component's state (e.g., set a validation error like setKeyError or inlineValidation) so an inline red helper label appears beside the input, or trigger the app's toast mechanism (e.g., call showToast or setToast) instead of alert; adjust the add-property handler (handleAddProperty / onAddProperty) to check the existing properties list and set the validation state or toast when a duplicate is detected, and ensure the input field uses that validation state to render the helper text and aria-describedby for accessibility.apps/console/src/routes/BackupResourceEditPage.tsx-79-79 (1)
79-79:⚠️ Potential issue | 🟡 MinorReplace
alert()with the app's toast/notification primitive.Native
alert()is blocking, not styled, and inconsistent with the rest of the console UI. Use the shared toast component for error feedback, and ideally surface the error inline near the Save button as well.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/routes/BackupResourceEditPage.tsx` at line 79, Replace the blocking native alert in BackupResourceEditPage (the alert(`Failed to update ${title.slice(0, -1)}: ${(err as Error).message}`) call) with the app's shared toast/notification primitive: import and call the toast API used across the console (e.g., toast.error(...) or showToast({type: 'error', title: 'Update failed', message: ...})) and pass the same formatted message; additionally set a local component state like saveError and update it with the error message so the UI can render an inline error message near the Save button (clear saveError when retrying or on success). Ensure you remove the alert() call and use the toast API plus the saveError state in the Save handler in BackupResourceEditPage.apps/console/src/components/AdditionalPropertiesEditor.tsx-30-41 (1)
30-41:⚠️ Potential issue | 🟡 MinorTrimmed for validation, stored raw — keys with whitespace slip through.
handleAddKeyvalidates!newKey.trim()and checks duplicates against the rawnewKey, but storesnewKeyverbatim on line 39. A user entering" foo "creates a key" foo "that won't collide with an existing"foo"and displays with stray whitespace. Normalize once and reuse.🔧 Suggested fix
const handleAddKey = () => { - if (!newKey.trim()) return - if (value && newKey in value) { - alert(`Key "${newKey}" already exists`) + const key = newKey.trim() + if (!key) return + if (value && key in value) { + alert(`Key "${key}" already exists`) return } // Initialize with default value from schema const defaultValue = itemSchema.type === "object" ? {} : itemSchema.default ?? "" - onChange({ ...value, [newKey]: defaultValue }) + onChange({ ...value, [key]: defaultValue }) setNewKey("") }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/components/AdditionalPropertiesEditor.tsx` around lines 30 - 41, handleAddKey uses newKey.trim() for validation but then stores the raw newKey, allowing keys with surrounding whitespace; fix by normalizing newKey once at start (e.g., const normalizedKey = newKey.trim()), use normalizedKey for the duplicate check (against value), use normalizedKey when adding the property in onChange (with defaultValue computed from itemSchema), and then clear the input with setNewKey(""); update all references in handleAddKey to the normalizedKey so whitespace can't create distinct keys.apps/console/src/routes/BackupResourceEditPage.tsx-65-65 (1)
65-65:⚠️ Potential issue | 🟡 MinorImport
FormEventas a named import instead of usingReact.FormEvent.The file uses
React.FormEventwithout importing theReactnamespace. With the modern JSX transform ("jsx": "react-jsx"), theReactnamespace is not automatically in scope. OnlyuseEffectanduseStateare imported fromreact. Update to use the named import:🔧 Proposed fix
-import { useEffect, useState } from "react" +import { useEffect, useState, type FormEvent } from "react" @@ - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = async (e: FormEvent) => {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/routes/BackupResourceEditPage.tsx` at line 65, The handler signature uses React.FormEvent but the React namespace isn't imported under the modern JSX transform; update the import from 'react' to include the named type FormEvent (alongside existing useEffect/useState), and change the function signature to use FormEvent (e.g., handleSubmit = async (e: FormEvent) => { ... }) so the type resolves correctly; locate the handleSubmit function and the top-level import that currently imports useEffect/useState and add FormEvent there.apps/console/src/components/AdditionalPropertiesEditor.tsx-38-38 (1)
38-38:⚠️ Potential issue | 🟡 MinorHandle
typearrays in default value initialization.
RJSFSchema.typesupportsstring | string[]per JSON Schema Draft 7 spec (e.g.,["object", "null"]). The current checkitemSchema.type === "object"only works for string types and fails for arrays, defaulting to""which may be invalid for object types.Normalize the type check:
Suggested fix
- const defaultValue = itemSchema.type === "object" ? {} : itemSchema.default ?? "" + const types = Array.isArray(itemSchema.type) ? itemSchema.type : [itemSchema.type] + const defaultValue = + itemSchema.default ?? + (types.includes("object") ? {} : types.includes("array") ? [] : "")🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/console/src/components/AdditionalPropertiesEditor.tsx` at line 38, The defaultValue initialization incorrectly treats itemSchema.type as a string only; update the check so it handles string or string[] (per RJSFSchema.type) by detecting arrays and testing includes('object') — e.g., replace the single equality check with a boolean like (Array.isArray(itemSchema.type) ? itemSchema.type.includes("object") : itemSchema.type === "object") when deciding to use {} versus itemSchema.default ?? "". Ensure you keep the existing fallback to itemSchema.default and then "" as before; reference variable defaultValue and the itemSchema.type property.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 5d22fe6b-1578-483e-8cac-0589d8d1adce
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (31)
apps/console/package.jsonapps/console/src/components/AdditionalPropertiesEditor.tsxapps/console/src/components/AdditionalPropertiesField.tsxapps/console/src/components/AdditionalPropertiesWidget.tsxapps/console/src/components/CustomObjectFieldTemplate.tsxapps/console/src/components/KeyValueEditor.tsxapps/console/src/components/MutuallyExclusiveField.tsxapps/console/src/components/SchemaForm.tsxapps/console/src/components/SourceWidget.tsxapps/console/src/components/StorageClassWidget.tsxapps/console/src/components/rjsf-templates.tsxapps/console/src/components/schema-form.cssapps/console/src/lib/keys-order.tsapps/console/src/lib/use-crd-schema.tsapps/console/src/routes/BackupCreatePage.tsxapps/console/src/routes/BackupPlanCreatePage.tsxapps/console/src/routes/BackupResourceCreatePage.tsxapps/console/src/routes/BackupResourceCreatePageWithData.tsxapps/console/src/routes/BackupResourceEditPage.tsxapps/console/src/routes/BackupResourceListPage.tsxapps/console/src/routes/BackupRestoreJobCreatePage.tsxapps/console/src/routes/ConsolePage.tsxapps/console/src/routes/TenantsPage.tsxapps/console/src/routes/backup-schemas.tsapps/console/src/routes/detail/ApplicationDetailPage.tsxapps/console/src/routes/detail/SecretsTab.tsxapps/console/src/routes/detail/VncTab.tsxapps/console/src/routes/sidebar-sections.tsxpackages/k8s-client/src/client.tspackages/k8s-client/src/hooks.tspackages/ui/src/components/layout/Sidebar.tsx
| const { schema, formData, onChange, readonly, disabled } = props | ||
|
|
||
| // Check if this is a free-form key-value object | ||
| // ONLY use KeyValueEditor for truly free-form objects where both keys and values are arbitrary | ||
| // This means x-kubernetes-preserve-unknown-fields OR additionalProperties: true (boolean, not a schema object) | ||
| const isFreeFormObject = | ||
| (!schema.properties || Object.keys(schema.properties).length === 0) && | ||
| ((schema as any)["x-kubernetes-preserve-unknown-fields"] === true || | ||
| schema.additionalProperties === true) | ||
|
|
||
| // If it's a free-form key-value object, use our custom editor | ||
| if (isFreeFormObject) { | ||
| return ( | ||
| <div className="field"> | ||
| {props.title && ( | ||
| <label className="control-label"> | ||
| {props.title} | ||
| {props.required && <span className="required">*</span>} | ||
| </label> | ||
| )} | ||
| {props.description && <p className="field-description">{props.description}</p>} | ||
| <KeyValueEditor | ||
| value={formData || {}} | ||
| onChange={onChange} | ||
| readonly={readonly || disabled} | ||
| /> | ||
| </div> | ||
| ) |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In @rjsf/utils v5.24, does the ObjectFieldTemplateProps interface include an onChange prop, or is onChange only exposed on FieldProps and WidgetProps?
💡 Result:
In @rjsf/utils v5.24, the ObjectFieldTemplateProps interface does not include an onChange prop. It includes props such as id, classNames, title, description, displayTitle, properties, required, readonly, disabled, registry, formContext, schema, uiSchema, and formData. Specific callbacks like onAddProperty (in later versions) or onAddClick (in v5) are present for adding properties, but no general onChange prop. The onChange prop is explicitly part of FieldProps and WidgetProps interfaces. FieldProps includes onChange: (newValue: T | undefined, path: FieldPathList, es?: ErrorSchema, id?: string) => void. WidgetProps includes onChange: (value: any, es?: ErrorSchema, id?: string) => void. ObjectFieldTemplate is a layout template for rendering object properties; it receives pre-rendered property content via the properties array and does not handle value changes directly. Change handling is managed by the underlying Field and Widget components.
Citations:
- 1: https://tessl.io/registry/tessl/npm-rjsf--core/5.24.0/files/docs/template-components.md
- 2: https://github.com/rjsf-team/react-jsonschema-form/blob/main/packages/utils/src/types.ts
- 3: https://cdn.jsdelivr.net/npm/@rjsf/utils@5.24.13/lib/types.d.ts
- 4: https://rjsf-team.github.io/react-jsonschema-form/docs/advanced-customization/custom-templates/
- 5: https://rjsf-team.github.io/react-jsonschema-form/docs/advanced-customization/custom-widgets-fields/
🏁 Script executed:
fd CustomObjectFieldTemplate.tsxRepository: cozystack/cozystack-ui
Length of output: 124
🏁 Script executed:
cat -n apps/console/src/components/CustomObjectFieldTemplate.tsx | head -50Repository: cozystack/cozystack-ui
Length of output: 2280
🏁 Script executed:
cat -n apps/console/src/components/CustomObjectFieldTemplate.tsxRepository: cozystack/cozystack-ui
Length of output: 4168
🏁 Script executed:
cat -n apps/console/src/components/KeyValueEditor.tsxRepository: cozystack/cozystack-ui
Length of output: 4019
onChange is not a prop of ObjectFieldTemplateProps — KeyValueEditor will throw a runtime error.
Per the RJSF v5 @rjsf/utils types, ObjectFieldTemplateProps does not expose onChange. Destructuring it on line 14 yields undefined, so when KeyValueEditor calls onChange(obj) on line 34 of its implementation, it throws TypeError: onChange is not a function. This completely breaks the free-form object editor (x-kubernetes-preserve-unknown-fields / additionalProperties: true).
The canonical way to propagate value changes from an ObjectFieldTemplate is through per-property props.properties[i].content children (lines 65, 73, 92 do this correctly). For a whole-object custom editor, register it as a Field (RegistryFieldsType) with FieldProps (which has onChange), not as an ObjectFieldTemplate.
Suggested fix
Option A (preferred): Extract the free-form branch into a dedicated Field registered under uiSchema["ui:field"], which receives a real onChange: FieldProps["onChange"].
Option B (quick): Replace isFreeFormObject branch with props.properties.map((prop) => prop.content) (like the default fallback), avoiding custom change handling entirely.
🧰 Tools
🪛 ESLint
[error] 21-21: Unexpected any. Specify a different type.
(@typescript-eslint/no-explicit-any)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/console/src/components/CustomObjectFieldTemplate.tsx` around lines 14 -
41, The free-form branch in CustomObjectFieldTemplate uses a nonexistent
onChange from ObjectFieldTemplateProps causing runtime errors when
KeyValueEditor calls onChange; fix by extracting that branch into a proper Field
(register under uiSchema["ui:field"]) so the component receives FieldProps
including a real onChange, or alternatively revert the free-form branch to
render props.properties.map(p => p.content) (matching the default
ObjectFieldTemplate) to avoid custom change handling; reference
ObjectFieldTemplateProps, KeyValueEditor, props.properties, FieldProps, and
uiSchema["ui:field"] when implementing the change.
| interface MutuallyExclusiveFieldProps { | ||
| schema: RJSFSchema | ||
| formData: Record<string, unknown> | undefined | ||
| handleChange: (data: Record<string, unknown> | undefined) => void | ||
| title?: string | ||
| description?: string | ||
| required?: boolean | ||
| } | ||
|
|
||
| export function MutuallyExclusiveField(props: MutuallyExclusiveFieldProps) { | ||
| const { schema, formData, onChange, title, description, required } = props | ||
| const handleChange = onChange |
There was a problem hiding this comment.
Interface declares handleChange, component destructures onChange — every interaction throws.
MutuallyExclusiveFieldProps on line 7 exposes handleChange, but line 14 destructures onChange from props. That yields undefined; line 15 then aliases handleChange = undefined, and the first call (handleSelect / handleClear / the nested <input> onChange) crashes with TypeError: handleChange is not a function. The component is non-functional as written — unit-test-level bug.
🐛 Suggested fix
export function MutuallyExclusiveField(props: MutuallyExclusiveFieldProps) {
- const { schema, formData, onChange, title, description, required } = props
- const handleChange = onChange
+ const { schema, formData, handleChange, title, description, required } = props
const properties = schema.properties || {}Also worth double-checking the caller side — the AI summary notes SchemaForm’s new wiring does not currently target MutuallyExclusiveField, so this may be dead on the main path today. Either wire it in and fix the prop, or drop the file until it’s needed.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/console/src/components/MutuallyExclusiveField.tsx` around lines 4 - 15,
The component MutuallyExclusiveField declares a prop named handleChange in
MutuallyExclusiveFieldProps but destructures onChange from props, causing
handleChange to be undefined and subsequent calls to fail; fix by making the
prop name consistent — either rename the interface member to onChange or
destructure handleChange from props in the MutuallyExclusiveField function, and
ensure the internal alias (currently const handleChange = onChange) uses the
correct identifier; also verify callers provide the matching prop name
(handleChange or onChange) to MutuallyExclusiveField to keep wiring consistent
with SchemaForm.
| function IconButton< | ||
| T = any, | ||
| S extends StrictRJSFSchema = RJSFSchema, | ||
| F extends FormContextType = any, | ||
| >(props: IconButtonProps<T, S, F>) { | ||
| const { icon, iconType, uiSchema, registry, className, ...btnProps } = props | ||
| return ( | ||
| <button | ||
| type="button" | ||
| className={className} | ||
| {...btnProps} | ||
| > | ||
| {icon} | ||
| </button> | ||
| ) | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's see the full file to understand the context
head -100 apps/console/src/components/rjsf-templates.tsxRepository: cozystack/cozystack-ui
Length of output: 2080
🏁 Script executed:
# Check IconButtonProps definition to see if it includes a type property
rg "IconButtonProps" --type tsx --type ts -A 5Repository: cozystack/cozystack-ui
Length of output: 94
🏁 Script executed:
# Search for IconButtonProps without type restriction
rg "IconButtonProps" -A 5 apps/console/src/components/rjsf-templates.tsxRepository: cozystack/cozystack-ui
Length of output: 362
🏁 Script executed:
# Check if FieldTemplateProps is used anywhere in the file
rg "FieldTemplateProps" apps/console/src/components/rjsf-templates.tsx
# Check if AdditionalPropertiesField is used anywhere in the file
rg "AdditionalPropertiesField" apps/console/src/components/rjsf-templates.tsxRepository: cozystack/cozystack-ui
Length of output: 164
🏁 Script executed:
# Get the full file to see if there's any usage of these imports
wc -l apps/console/src/components/rjsf-templates.tsx
# Get full content
cat -n apps/console/src/components/rjsf-templates.tsxRepository: cozystack/cozystack-ui
Length of output: 2578
🏁 Script executed:
# Search for usage of customTemplates in the codebase
rg "customTemplates" apps/console/src/ -n
# Also search for RJSF Form usage to understand the context
rg "Form" apps/console/src/components/ -n | head -20Repository: cozystack/cozystack-ui
Length of output: 2848
🏁 Script executed:
# Check SchemaForm to see how Form is used and how onSubmit is handled
sed -n '120,160p' apps/console/src/components/SchemaForm.tsx
# Also check AdditionalPropertiesField for context on submit
sed -n '15,30p' apps/console/src/components/AdditionalPropertiesField.tsxRepository: cozystack/cozystack-ui
Length of output: 1258
🏁 Script executed:
# Check all RJSF Form usages to see if any don't override SubmitButton
rg "Form" apps/console/src/components/ -B 5 -A 15 | grep -A 20 "templates=" | head -80Repository: cozystack/cozystack-ui
Length of output: 6574
🏁 Script executed:
# Verify that IconButtonProps from `@rjsf/utils` doesn't define a type property
# Let's check if there's any node_modules or package info
fd "package.json" apps/console/ -x cat {}
# Also check the actual usage pattern to confirm all buttons should be type="button" except SubmitButton
rg "ButtonTemplates" apps/console/src/components/ -A 20 | head -60Repository: cozystack/cozystack-ui
Length of output: 5457
SubmitButton hardcoded to type="button" creates a footgun for form submission.
IconButton always sets type="button", so when used for SubmitButton (lines 54-56), it breaks native form submit semantics. Currently, all forms in the codebase mitigate this by explicitly overriding SubmitButton to null or hiding it via uiSchema, but this is a brittle pattern that invites bugs when the templates are reused. The other button types (Add, Remove, MoveUp, MoveDown) correctly need type="button", but SubmitButton should be type="submit".
♻️ Suggested fix
-function IconButton<
+function IconButton<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
->(props: IconButtonProps<T, S, F>) {
- const { icon, iconType, uiSchema, registry, className, ...btnProps } = props
+>(props: IconButtonProps<T, S, F> & { type?: "button" | "submit" }) {
+ const { icon, iconType, uiSchema, registry, className, type = "button", ...btnProps } = props
return (
<button
- type="button"
+ type={type}
className={className}
{...btnProps}
>
{icon}
</button>
)
}
@@
- SubmitButton: (props) => (
- <IconButton {...props} icon="Submit" className={buttonClassName} />
- ),
+ SubmitButton: (props) => (
+ <IconButton {...props} type="submit" icon="Submit" className={buttonClassName} />
+ ),Also applies to: 54-56
Remove unused imports: FieldTemplateProps (line 5) and AdditionalPropertiesField (line 14).
🧰 Tools
🪛 ESLint
[error] 16-16: Fast refresh only works when a file only exports components. Move your component(s) to a separate file. If all exports are HOCs, add them to the extraHOCs option.
(react-refresh/only-export-components)
[error] 17-17: Unexpected any. Specify a different type.
(@typescript-eslint/no-explicit-any)
[error] 19-19: Unexpected any. Specify a different type.
(@typescript-eslint/no-explicit-any)
[error] 21-21: 'iconType' is assigned a value but never used.
(@typescript-eslint/no-unused-vars)
[error] 21-21: 'uiSchema' is assigned a value but never used.
(@typescript-eslint/no-unused-vars)
[error] 21-21: 'registry' is assigned a value but never used.
(@typescript-eslint/no-unused-vars)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/console/src/components/rjsf-templates.tsx` around lines 16 - 31,
IconButton currently hardcodes type="button", which breaks SubmitButton; update
IconButton (used by SubmitButton, AddButton, RemoveButton, MoveUpButton,
MoveDownButton) to not force the button type—either accept and spread an
explicit type prop from IconButtonProps or default to leaving type undefined so
callers can set type="submit" for SubmitButton and type="button" for control
buttons; then change the SubmitButton usage to supply type="submit" (or ensure
the SubmitButton component passes type="submit" to IconButton). Also remove the
unused imports FieldTemplateProps and AdditionalPropertiesField from this file.
| const currentOption = value | ||
| ? options.find((opt: string) => value[opt] !== undefined && value[opt] !== null) | ||
| : undefined | ||
|
|
||
| const [selected, setSelected] = useState<string | undefined>(currentOption) |
There was a problem hiding this comment.
Selected state won't re-sync when value changes externally.
useState(currentOption) captures only the initial currentOption. If the form data loads asynchronously (common in edit flows where value is undefined on first render and populated later), or is reset by the parent, the radio selection will be stuck on the initial value and diverge from value.
Prefer deriving selected directly from value (single source of truth), or sync with useEffect:
♻️ Proposed fix (derive, don't cache)
- const currentOption = value
- ? options.find((opt: string) => value[opt] !== undefined && value[opt] !== null)
- : undefined
-
- const [selected, setSelected] = useState<string | undefined>(currentOption)
+ const selected = value
+ ? options.find((opt: string) => value[opt] !== undefined && value[opt] !== null)
+ : undefinedThen drop setSelected calls from handleSelect/handleClear — the onChange already drives the value, which drives selected.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/console/src/components/SourceWidget.tsx` around lines 10 - 14, The local
selected state is initialized from currentOption and won't update when value
changes externally; replace it by deriving selected from value (compute
currentOption each render) or add a useEffect that setsSelected(currentOption)
whenever value or options change, and remove direct setSelected calls from
handleSelect/handleClear so the component's radio selection is driven solely by
the incoming value; reference currentOption, selected, setSelected,
handleSelect, handleClear, value, and options when making the change.
| <label className="flex items-start gap-2 cursor-pointer"> | ||
| <input | ||
| type="radio" | ||
| name={`${id}-source`} | ||
| checked={isSelected} | ||
| onChange={() => handleSelect(option)} | ||
| className="mt-0.5 size-4 rounded-full border-slate-300 text-blue-600 focus:ring-blue-500" | ||
| /> | ||
| <div className="flex-1"> | ||
| <div className="text-sm font-medium text-slate-900">{option}</div> | ||
| {optionDescription && ( | ||
| <div className="text-xs text-slate-500 mt-0.5">{optionDescription}</div> | ||
| )} | ||
| {isSelected && renderFieldInput(option)} | ||
| </div> | ||
| </label> | ||
| </div> |
There was a problem hiding this comment.
Nested form inputs inside a <label> cause click hijacking & a11y issues.
renderFieldInput(option) renders <input type="text"> children at line 113, but they are descendants of the <label> that wraps the radio (line 100). Clicking any sub-field input will also toggle/activate the associated radio and may steal focus, and screen readers will read nested controls as part of the radio's accessible name.
Close the <label> around just the radio + heading, and render renderFieldInput(option) as a sibling:
♻️ Proposed structure
- <label className="flex items-start gap-2 cursor-pointer">
+ <label className="flex items-start gap-2 cursor-pointer">
<input
type="radio"
name={`${id}-source`}
checked={isSelected}
onChange={() => handleSelect(option)}
className="mt-0.5 size-4 rounded-full border-slate-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<div className="text-sm font-medium text-slate-900">{option}</div>
{optionDescription && (
<div className="text-xs text-slate-500 mt-0.5">{optionDescription}</div>
)}
- {isSelected && renderFieldInput(option)}
</div>
- </label>
+ </label>
+ {isSelected && renderFieldInput(option)}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <label className="flex items-start gap-2 cursor-pointer"> | |
| <input | |
| type="radio" | |
| name={`${id}-source`} | |
| checked={isSelected} | |
| onChange={() => handleSelect(option)} | |
| className="mt-0.5 size-4 rounded-full border-slate-300 text-blue-600 focus:ring-blue-500" | |
| /> | |
| <div className="flex-1"> | |
| <div className="text-sm font-medium text-slate-900">{option}</div> | |
| {optionDescription && ( | |
| <div className="text-xs text-slate-500 mt-0.5">{optionDescription}</div> | |
| )} | |
| {isSelected && renderFieldInput(option)} | |
| </div> | |
| </label> | |
| </div> | |
| <label className="flex items-start gap-2 cursor-pointer"> | |
| <input | |
| type="radio" | |
| name={`${id}-source`} | |
| checked={isSelected} | |
| onChange={() => handleSelect(option)} | |
| className="mt-0.5 size-4 rounded-full border-slate-300 text-blue-600 focus:ring-blue-500" | |
| /> | |
| <div className="flex-1"> | |
| <div className="text-sm font-medium text-slate-900">{option}</div> | |
| {optionDescription && ( | |
| <div className="text-xs text-slate-500 mt-0.5">{optionDescription}</div> | |
| )} | |
| </div> | |
| </label> | |
| {isSelected && renderFieldInput(option)} | |
| </div> |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/console/src/components/SourceWidget.tsx` around lines 100 - 116, The
radio's <label> currently wraps the entire option including nested inputs
returned by renderFieldInput(option), causing click hijacking and a11y issues;
update the JSX so the <label> (containing the <input type="radio">, the option
title text and optional optionDescription) is closed before rendering
renderFieldInput(option) and then render renderFieldInput(option) as a sibling
element (outside the label) only when isSelected is true; ensure handleSelect
remains on the radio's onChange and that styles/layout remain unchanged while
removing nested form controls from inside the label.
| useEffect(() => { | ||
| if (resource?.spec) { | ||
| setFormData(resource.spec) | ||
| } | ||
| }, [resource]) |
There was a problem hiding this comment.
User edits will be overwritten on any resource refetch.
This effect reassigns formData whenever the resource reference changes (e.g., React Query refetch on window focus, interval polling, cache invalidation after sibling mutations). If the user is mid-edit, their unsaved changes silently disappear. Initialize once (e.g., keyed on resource.metadata.resourceVersion or only when formData is empty), or seed state lazily via useState once the resource is present.
🔧 Example fix: initialize only when resourceVersion changes
- const [formData, setFormData] = useState<any>({})
+ const [formData, setFormData] = useState<any>({})
+ const initializedVersionRef = useRef<string | undefined>(undefined)
@@
- useEffect(() => {
- if (resource?.spec) {
- setFormData(resource.spec)
- }
- }, [resource])
+ useEffect(() => {
+ const rv = resource?.metadata?.resourceVersion
+ if (resource?.spec && rv !== initializedVersionRef.current) {
+ initializedVersionRef.current = rv
+ setFormData(resource.spec)
+ }
+ }, [resource])🧰 Tools
🪛 ESLint
[error] 61-61: Error: Calling setState synchronously within an effect can trigger cascading renders
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
- Update external systems with the latest state from React.
- Subscribe for updates from some external system, calling setState in a callback function when external state changes.
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
/home/jailuser/git/apps/console/src/routes/BackupResourceEditPage.tsx:61:7
59 | useEffect(() => {
60 | if (resource?.spec) {
61 | setFormData(resource.spec)
| ^^^^^^^^^^^ Avoid calling setState() directly within an effect
62 | }
63 | }, [resource])
64 |
(react-hooks/set-state-in-effect)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/console/src/routes/BackupResourceEditPage.tsx` around lines 59 - 63, The
effect currently overwrites formData on every resource refetch; change
initialization so formData is only set when appropriate (e.g., on first load or
when the resource version changes) by modifying the useEffect that calls
setFormData(resource.spec). Update the effect to guard with a check such as only
set when formData is empty or when resource.metadata.resourceVersion has changed
(instead of reacting to resource reference changes), so user edits in-flight are
not clobbered; keep the same identifiers (useEffect, setFormData, formData,
resource.spec, resource.metadata.resourceVersion) to locate and update the
logic.
| const { data } = useK8sGet<K8sResource<unknown, unknown> & SecretLike>( | ||
| { ...TENANT_SECRETS_REF, namespace, name }, | ||
| { enabled: revealed }, | ||
| { ...SECRETS_REF, namespace, name }, | ||
| { enabled: shouldReveal }, | ||
| ) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Confirm the shape returned by the tenantsecrets API vs v1/secrets
rg -nP -C3 'tenantsecrets|TenantSecret' --type=ts
rg -nP -C3 'useK8sGet\s*<' apps/console/src/routes/detail/SecretsTab.tsxRepository: cozystack/cozystack-ui
Length of output: 1548
🏁 Script executed:
# Check SecretRow function signature and props
sed -n '35,65p' apps/console/src/routes/detail/SecretsTab.tsxRepository: cozystack/cozystack-ui
Length of output: 993
🏁 Script executed:
# Check SecretItem and how it calls SecretRow
sed -n '145,175p' apps/console/src/routes/detail/SecretsTab.tsxRepository: cozystack/cozystack-ui
Length of output: 981
🏁 Script executed:
# Check the full SecretsTab function to see how it passes to SecretItem
sed -n '175,210p' apps/console/src/routes/detail/SecretsTab.tsxRepository: cozystack/cozystack-ui
Length of output: 1175
🏁 Script executed:
# Find SECRETS_REF definition
rg -n 'SECRETS_REF\s*=' apps/console/src/routes/detail/SecretsTab.tsxRepository: cozystack/cozystack-ui
Length of output: 91
🏁 Script executed:
# Read SECRETS_REF definition
sed -n '13,18p' apps/console/src/routes/detail/SecretsTab.tsxRepository: cozystack/cozystack-ui
Length of output: 148
Per-key reveal fetch always targets v1/secrets, even when listing tenantsecrets.
SecretRow hardcodes SECRETS_REF (core v1/secrets) for its useK8sGet, but SecretsTab may have listed items from core.cozystack.io/v1alpha1/tenantsecrets. For the Tenant branch this GET hits a different API than the one that produced the list; it will silently 404 or (if a same-named Secret exists in the namespace) fetch a different object than intended. The UI partially hides this because fullValue falls back to decodeValue(base64Value), so the only symptom may be an unused failing network request — but it will also surface the wrong object if names collide.
Pass the effective apiRef down from SecretsTab instead of hardcoding:
🔧 Proposed fix
function SecretRow({
namespace,
name,
keyName,
base64Value,
forceReveal,
+ apiRef,
}: {
namespace: string
name: string
keyName: string
base64Value: string
forceReveal?: boolean
+ apiRef: { apiGroup: string; apiVersion: string; plural: string }
}) {
...
const { data } = useK8sGet<K8sResource<unknown, unknown> & SecretLike>(
- { ...SECRETS_REF, namespace, name },
+ { ...apiRef, namespace, name },
{ enabled: shouldReveal },
)…and thread apiRef through SecretItem → SecretRow.
Alternatively, since the list response already contains secret.data, drop the per-row useK8sGet entirely and rely on the decoded base64Value.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/console/src/routes/detail/SecretsTab.tsx` around lines 50 - 53,
SecretRow currently hardcodes SECRETS_REF when calling useK8sGet which causes
per-row GETs to always target core/v1 secrets even when the list in SecretsTab
used another apiRef (e.g., core.cozystack.io/v1alpha1/tenantsecrets); update
SecretsTab to pass the effective apiRef down through SecretItem into SecretRow
and change the useK8sGet call in SecretRow to use that passed apiRef instead of
SECRETS_REF (or remove the per-row useK8sGet entirely and use the
already-present list data.secret.data/base64Value to compute fullValue),
ensuring the GET targets the same API that produced the list.
| let mounted = true | ||
|
|
||
| // Build WebSocket URL through kubectl proxy | ||
| const wsUrl = `ws://localhost:8001/apis/subresources.kubevirt.io/v1/namespaces/${ns}/virtualmachineinstances/${instance.metadata.name}/vnc` |
There was a problem hiding this comment.
Hardcoded ws://localhost:8001/... will only work behind a local kubectl proxy.
This endpoint is pinned to ws://localhost:8001, so the VNC tab will silently fail for any deployment that isn't sitting behind kubectl proxy on the developer's machine. On an HTTPS-served console the browser will also block ws:// due to mixed content. Since this is user-facing behavior (not just a WIP stub), it should at least:
- Derive the scheme from
window.location.protocol(wss:on HTTPS pages). - Derive the host from the current origin, or route through an app-defined proxy path, so the same build works in dev and prod.
🔧 Sketch
- const wsUrl = `ws://localhost:8001/apis/subresources.kubevirt.io/v1/namespaces/${ns}/virtualmachineinstances/${instance.metadata.name}/vnc`
+ const scheme = window.location.protocol === "https:" ? "wss" : "ws"
+ const wsUrl = `${scheme}://${window.location.host}/apis/subresources.kubevirt.io/v1/namespaces/${ns}/virtualmachineinstances/${instance.metadata.name}/vnc`The same change is needed in handleReconnect at line 125 (or better, extract both call sites to a single helper — see the duplication comment below).
Also applies to: 125-125
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/console/src/routes/detail/VncTab.tsx` at line 26, The hardcoded wsUrl in
VncTab.tsx (and the duplicate in handleReconnect) uses ws://localhost:8001 which
breaks non-proxy and HTTPS deployments; replace it with a dynamic builder that
derives the scheme from window.location.protocol (use "wss:" when protocol is
"https:"), uses window.location.host or window.location.origin for the host, and
appends the existing path
`/apis/subresources.kubevirt.io/v1/namespaces/${ns}/virtualmachineinstances/${instance.metadata.name}/vnc`;
extract this logic into a single helper (e.g., buildVncWebSocketUrl) and call it
from both the wsUrl initialization and handleReconnect to avoid duplication and
ensure proper wss/ws selection and host resolution.
| const handleReconnect = () => { | ||
| // Clear container and reinitialize | ||
| if (vncContainerRef.current) { | ||
| vncContainerRef.current.innerHTML = "" | ||
| setConnecting(true) | ||
| setError(null) | ||
|
|
||
| const wsUrl = `ws://localhost:8001/apis/subresources.kubevirt.io/v1/namespaces/${ns}/virtualmachineinstances/${instance.metadata.name}/vnc` | ||
|
|
||
| import("@novnc/novnc/lib/rfb").then((module) => { | ||
| if (!vncContainerRef.current) return | ||
|
|
||
| // The module has nested default: module.default.default is the RFB constructor | ||
| const RFB = (module as any).default?.default || module.default || module | ||
|
|
||
| try { | ||
| const rfb = new RFB(vncContainerRef.current, wsUrl, { credentials: {} }) | ||
| rfb.scaleViewport = true | ||
| rfb.resizeSession = false | ||
|
|
||
| rfb.addEventListener("connect", () => { | ||
| setConnecting(false) | ||
| setConnected(true) | ||
| setError(null) | ||
| }) | ||
|
|
||
| rfb.addEventListener("disconnect", (e: any) => { | ||
| setConnecting(false) | ||
| setConnected(false) | ||
| if (!e.detail.clean) { | ||
| setError(`Disconnected: ${e.detail.reason || "unknown reason"}`) | ||
| } | ||
| }) | ||
|
|
||
| rfb.addEventListener("securityfailure", (e: any) => { | ||
| setConnecting(false) | ||
| setConnected(false) | ||
| setError(`Security failure: ${e.detail.status || "authentication failed"}`) | ||
| }) | ||
|
|
||
| rfbRef.current = rfb | ||
| } catch (err) { | ||
| setConnecting(false) | ||
| setError(`Failed to reconnect: ${(err as Error).message}`) | ||
| } | ||
| }).catch((err) => { | ||
| setConnecting(false) | ||
| setError(`Failed to load VNC library: ${err.message}`) | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
handleReconnect duplicates useEffect init and leaks the previous RFB client.
Two issues in one:
- Leak on reconnect.
handleReconnectclearsvncContainerRef.current.innerHTMLbut never callsrfbRef.current.disconnect()before constructing a newRFB. The previous client (and its WebSocket / event listeners) is orphaned on every reconnect. - Duplicated init logic. Lines 127–166 re-implement what lines 29–84 already do, so any fix (URL derivation, error handling, typing) has to be made twice. It also lacks the
mountedguard, so unmounting during the dynamicimport(...)will still callsetState.
Suggest extracting a single connectRfb(container) helper and having both the effect and the reconnect handler call it after first disposing rfbRef.current:
🔧 Sketch
+ const connectRfb = async (container: HTMLDivElement, isMounted: () => boolean) => {
+ if (rfbRef.current) {
+ try { rfbRef.current.disconnect() } catch { /* ignore */ }
+ rfbRef.current = null
+ }
+ const scheme = window.location.protocol === "https:" ? "wss" : "ws"
+ const wsUrl = `${scheme}://${window.location.host}/apis/subresources.kubevirt.io/v1/namespaces/${ns}/virtualmachineinstances/${instance.metadata.name}/vnc`
+ const mod = await import("@novnc/novnc/lib/rfb")
+ if (!isMounted()) return
+ const RFB = (mod as any).default?.default ?? (mod as any).default ?? mod
+ const rfb = new RFB(container, wsUrl, { credentials: {} })
+ rfb.scaleViewport = true
+ rfb.resizeSession = false
+ rfb.addEventListener("connect", () => { if (isMounted()) { setConnecting(false); setConnected(true); setError(null) } })
+ rfb.addEventListener("disconnect", (e: any) => {
+ if (!isMounted()) return
+ setConnecting(false); setConnected(false)
+ if (!e.detail?.clean) setError(`Disconnected: ${e.detail?.reason ?? "unknown reason"}`)
+ })
+ rfb.addEventListener("securityfailure", (e: any) => {
+ if (!isMounted()) return
+ setConnecting(false); setConnected(false)
+ setError(`Security failure: ${e.detail?.status ?? "authentication failed"}`)
+ })
+ rfbRef.current = rfb
+ }Then useEffect and handleReconnect both reduce to calling connectRfb(container, …) inside a try/catch.
🧰 Tools
🪛 ESLint
[error] 131-131: Unexpected any. Specify a different type.
(@typescript-eslint/no-explicit-any)
[error] 144-144: Unexpected any. Specify a different type.
(@typescript-eslint/no-explicit-any)
[error] 152-152: Unexpected any. Specify a different type.
(@typescript-eslint/no-explicit-any)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/console/src/routes/detail/VncTab.tsx` around lines 118 - 168,
handleReconnect duplicates the initialization logic from the useEffect and leaks
the previous RFB client by not disposing it; refactor by extracting the shared
connection logic into a single helper (e.g., connectRfb(container: HTMLElement))
that performs the dynamic import, creates RFB, attaches listeners, and respects
the mounted guard, then update both useEffect and handleReconnect to first call
rfbRef.current?.disconnect() (or otherwise dispose the existing client) and then
call connectRfb(vncContainerRef.current); ensure the helper returns/sets
rfbRef.current and that all setState calls are guarded by the mounted flag to
avoid updates after unmount.
| <td className="px-4 py-3"> | ||
| <Link to={`/console/tenants/${name}/edit`}> | ||
| <Button variant="outline" size="sm"> | ||
| <Edit className="size-3" /> Edit | ||
| </Button> | ||
| </Link> | ||
| </td> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find the edit route definition and loader to confirm the expected param
rg -nP --type=ts --type=tsx -C3 'tenants/[^"'\'']*edit|/tenants/:\w+' apps/console/src 2>/dev/null
rg -nP -C3 'tenantDisplayName' apps/console/src
fd -e ts -e tsx 'Tenant.*Edit|TenantEdit' apps/console/srcRepository: cozystack/cozystack-ui
Length of output: 2718
🏁 Script executed:
#!/bin/bash
# Find route configuration and edit component
find apps/console/src -name "*.tsx" -o -name "*.ts" | grep -E "(route|main|app)" | head -20
# Search for the edit component or loader
rg -l "tenants.*edit" apps/console/src --type=tsx --type=ts
# Look for route definitions with 'edit' pattern
rg -nC5 "path.*edit|:.*edit|tenants/:.*" apps/console/src --type=tsx --type=ts | head -50
# Check if there's a router configuration
fd -e tsx -e ts "router\|route\|app" apps/console/src | head -20Repository: cozystack/cozystack-ui
Length of output: 1037
🏁 Script executed:
#!/bin/bash
# Search for how the edit route param is used
rg -nC3 "useParams|useSearchParams" apps/console/src --type=tsx --type=ts
# Look for TenantEdit or edit page files
fd -i tenant apps/console/src --type=f | grep -i edit
# Find all route paths
rg -nC2 "path:" apps/console/src --type=tsx --type=ts | head -60Repository: cozystack/cozystack-ui
Length of output: 122
🏁 Script executed:
#!/bin/bash
# Find route definitions more broadly
rg -n "path.*edit\|:tenantId\|:tenant\|/tenants/" apps/console/src --max-count=30
# Check App.tsx for routes
cat -n apps/console/src/App.tsx | head -100
# Look for useParams usage to see how route params are accessed
rg -n "useParams" apps/console/src -A 2Repository: cozystack/cozystack-ui
Length of output: 5225
🏁 Script executed:
#!/bin/bash
# Find ConsolePage.tsx and look for route definitions
cat -n apps/console/src/routes/ConsolePage.tsx | head -150
# Search for tenant route definitions
rg -n "tenants" apps/console/src/routes/ConsolePage.tsx -A 3 -B 3Repository: cozystack/cozystack-ui
Length of output: 4169
🏁 Script executed:
#!/bin/bash
# Check if there's a TenantEditPage or tenant edit component
fd -name "*Tenant*" apps/console/src --type=f
# Search for any tenant edit references
rg -n "TenantEdit\|tenant.*edit" apps/console/src -i
# Check if a catch-all route would catch the tenants/:name/edit path
cat -n apps/console/src/routes/ConsolePage.tsx | tail -20Repository: cozystack/cozystack-ui
Length of output: 1209
🏁 Script executed:
#!/bin/bash
# Check how ApplicationEditRoute uses the name parameter
cat -n apps/console/src/routes/detail/ApplicationEditRoute.tsx | head -80Repository: cozystack/cozystack-ui
Length of output: 2138
🏁 Script executed:
#!/bin/bash
# Check if there's a TenantEditPage or any tenant editing functionality
fd "tenant" apps/console/src --type=f -i | xargs grep -l "edit" 2>/dev/null
# Check what routes are currently matching for the tenants/:name/edit path
rg -n "TenantNamespace\|TenantEdit\|tenant.*edit" apps/console/src -i
# Verify the route catch-all behavior - does it try to fetch ApplicationInstance for tenants?
rg -C5 "plural.*tenants" apps/console/srcRepository: cozystack/cozystack-ui
Length of output: 879
🏁 Script executed:
#!/bin/bash
# Check if TenantsPage has edit functionality or if it's trying to route to a non-existent page
cat -n apps/console/src/routes/TenantsPage.tsx | grep -A 5 -B 5 "edit\|Edit"
# Count total lines in TenantsPage.tsx
wc -l apps/console/src/routes/TenantsPage.tsx
# Check if there's any TenantEdit or similar component
find apps/console/src -type f -name "*Tenant*"Repository: cozystack/cozystack-ui
Length of output: 1263
🏁 Script executed:
#!/bin/bash
# Check what tenantDisplayName actually returns
cat -n apps/console/src/lib/tenant-context.tsx | grep -A 10 "function tenantDisplayName"
# Check the displayName function it calls
cat -n apps/console/src/lib/tenant-context.tsx | grep -B 5 -A 10 "function displayName"Repository: cozystack/cozystack-ui
Length of output: 847
Edit link uses display name instead of resource identifier.
The link at line 122 uses tenantDisplayName(t) to construct the route parameter. Since tenantDisplayName() strips the "tenant-" prefix from t.metadata.name, this results in an incorrect route param. For example, a tenant with metadata.name: "tenant-example" becomes the URL path /console/tenants/example/edit. When this route is matched by the catch-all pattern :plural/:name/edit, the ApplicationEditRoute will attempt to fetch a resource named "example" in the "tenants" plural, which does not exist—the actual resource is "tenant-example".
Use the canonical identifier t.metadata.name instead:
Proposed fix
- <td className="px-4 py-3">
- <Link to={`/console/tenants/${name}/edit`}>
+ <td className="px-4 py-3">
+ <Link to={`/console/tenants/${encodeURIComponent(t.metadata.name)}/edit`}>
<Button variant="outline" size="sm">
<Edit className="size-3" /> Edit
</Button>
</Link>
</td>Note: This fix assumes a dedicated tenant edit route handler exists or will be added to handle the tenants/:name/edit path. Currently, the catch-all ApplicationEditRoute is used, which expects application resources and will not work for tenants.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/console/src/routes/TenantsPage.tsx` around lines 121 - 127, The Edit
link builds the route using the display name (tenantDisplayName(t)) which strips
the "tenant-" prefix and breaks resource lookup; update the Link target to use
the canonical identifier t.metadata.name instead (replace tenantDisplayName(t)
with t.metadata.name in the Link to={`/console/tenants/${...}/edit`} inside
TenantsPage.tsx) so the route param matches the actual resource name expected by
the edit route handler.
Enable automatic data refresh every 5 seconds on application detail page. This ensures status updates are visible without manual page reload, especially important after creating new resources. Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
…ole home After deleting a resource, redirect to the resource type list page (e.g., /console/buckets) instead of the console home page. This provides better UX by keeping the user in the context of the resource type they were working with. Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Change Deploy buttons to navigate to /console/marketplace instead of /marketplace to maintain console navigation context. This prevents users from being redirected to the standalone marketplace when deploying from console resource lists. Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Change deploy buttons to navigate to /console/{appName} instead of
/console/marketplace/{appName}. The correct URL structure is:
- From /marketplace → /marketplace/{appName}
- From /console → /console/{appName}
Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Add /console/new/:appName route to allow creating applications directly from console without switching to marketplace. This keeps users in the console context and provides consistent UX. URL structure: - /marketplace/:appName - create from marketplace - /console/new/:appName - create from console Signed-off-by: ohotnikov.ivan <ohotnikov.ivan@e-queo.net>
Summary
Major UI/UX improvements for console application forms and resource management:
Key Features
Storage Class Widget
Automatically detects and provides dropdown for storage class selection in all forms with
storageClassfield.Backup Management
Full backup workflow UI:
Form Field Editors
Secrets Display
Test Plan
includeNamespaces)Summary by CodeRabbit
New Features
Improvements